Published on

react+ts+tusd实现文件上传

Authors
  • avatar
    Name
    刘十三
    Twitter

安装tusd

services:
  tusd:
    # 使用官方最新镜像
    image: tusproject/tusd:latest
    # 容器名称,方便管理
    container_name: tusd-server
    # 开机自启
    restart: always
    # 端口映射:主机端口 8080 -> 容器端口 8080
    ports:
      - '8080:8080'
    # 存储卷挂载:主机上传目录 -> 容器内目录
    volumes:
      - ./volumes/uploads:/srv/tusd-data
    # tusd 启动参数(核心配置)
    command:
      - -port=8080
      - -upload-dir=/srv/tusd-data
      - -cors-allow-origin=.*
      - -max-size=10737418240
    # 日志配置(可选,优化日志输出)
    logging:
      driver: 'json-file'
      options:
        max-size: '10m'
        max-file: '3'
docker-compose up -d

React + TypeScript 集成tusd

安装依赖

npm install tus-js-client

示例代码

import React, { useState, useRef, useCallback } from 'react'
import * as tus from 'tus-js-client'

type UploadState = 'idle' | 'uploading' | 'paused' | 'completed' | 'error'

const App: React.FC = () => {
  const [selectedFile, setSelectedFile] = useState<File | null>(null)
  const [uploadState, setUploadState] = useState<UploadState>('idle')
  const [uploadProgress, setUploadProgress] = useState<number>(0)
  const [errorMessage, setErrorMessage] = useState<string>('')
  const [fileUrl, setFileUrl] = useState<string>('')
  const [bytesUploaded, setBytesUploaded] = useState<number>(0)
  const [bytesTotal, setBytesTotal] = useState<number>(0)
  const [uploadSpeed, setUploadSpeed] = useState<number>(0)
  const [timeRemaining, setTimeRemaining] = useState<number>(0)
  const lastUpdateTime = useRef<number>(0)
  const lastBytesUploaded = useRef<number>(0)
  const upload = useRef<tus.Upload | null>(null)

  const tusdServerUrl = 'http://localhost:8080/files'

  const formatBytes = (bytes: number): string => {
    if (bytes === 0) return '0 B'
    const k = 1024
    const sizes = ['B', 'KB', 'MB', 'GB']
    const i = Math.floor(Math.log(bytes) / Math.log(k))
    return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
  }

  const formatTime = (seconds: number): string => {
    if (seconds < 60) return `${seconds}`
    const minutes = Math.floor(seconds / 60)
    const secs = seconds % 60
    if (minutes < 60) return `${minutes}${secs}`
    const hours = Math.floor(minutes / 60)
    const mins = minutes % 60
    return `${hours}小时${mins}`
  }

  const handleUploadComplete = (file: File, url: string) => {
    console.log('文件上传完成:', file.name, '地址:', url)
  }

  const handleUploadError = (error: Error) => {
    console.error('上传失败:', error)
  }

  const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files
    if (!files || files.length === 0) return

    const file = files[0]
    setSelectedFile(file)
    setUploadState('idle')
    setUploadProgress(0)
    setErrorMessage('')
    setBytesUploaded(0)
    setBytesTotal(file.size)
    setUploadSpeed(0)
    setTimeRemaining(0)
    lastUpdateTime.current = 0
    lastBytesUploaded.current = 0
  }, [])

  const startUpload = useCallback(async () => {
    if (!selectedFile) {
      setErrorMessage('请先选择文件')
      return
    }

    try {
      setUploadState('uploading')
      setErrorMessage('')

      const uploadInstance = new tus.Upload(selectedFile, {
        endpoint: tusdServerUrl,
        metadata: {
          filename: selectedFile.name,
          filetype: selectedFile.type,
        },
        onProgress: (bytesUploaded, bytesTotal) => {
          const now = Date.now()
          const progress = Math.floor((bytesUploaded / bytesTotal) * 100)

          setUploadProgress(progress)
          setBytesUploaded(bytesUploaded)
          setBytesTotal(bytesTotal)

          if (lastUpdateTime.current > 0) {
            const timeDiff = (now - lastUpdateTime.current) / 1000
            const bytesDiff = bytesUploaded - lastBytesUploaded.current

            if (timeDiff > 0) {
              const speed = bytesDiff / timeDiff
              setUploadSpeed(speed)

              const remaining = bytesTotal - bytesUploaded
              if (speed > 0) {
                setTimeRemaining(Math.ceil(remaining / speed))
              }
            }
          }

          lastUpdateTime.current = now
          lastBytesUploaded.current = bytesUploaded
        },
        onSuccess: () => {
          setUploadState('completed')
          setUploadProgress(100)
          const url = uploadInstance.url
          setFileUrl(url || '')
          handleUploadComplete(selectedFile, url!)
        },
        onError: (error) => {
          setUploadState('error')
          setErrorMessage(`上传失败:${error.message}`)
          handleUploadError(error)
        },
      })

      const previousUploads = await uploadInstance.findPreviousUploads()
      if (previousUploads.length > 0) {
        uploadInstance.resumeFromPreviousUpload(previousUploads[0])
      }

      upload.current = uploadInstance
      uploadInstance.start()
    } catch (error) {
      const err = error as Error
      setUploadState('error')
      setErrorMessage(`上传初始化失败:${err.message}`)
      handleUploadError(err)
    }
  }, [selectedFile, tusdServerUrl])

  const pauseUpload = useCallback(() => {
    if (upload.current && uploadState === 'uploading') {
      upload.current.abort()
      setUploadState('paused')
    }
  }, [uploadState])

  const cancelUpload = useCallback(() => {
    if (upload.current) {
      upload.current.abort()
      upload.current = null
    }
    setSelectedFile(null)
    setUploadState('idle')
    setUploadProgress(0)
    setErrorMessage('')
    setFileUrl('')
    setBytesUploaded(0)
    setBytesTotal(0)
    setUploadSpeed(0)
    setTimeRemaining(0)
    lastUpdateTime.current = 0
    lastBytesUploaded.current = 0
  }, [])

  return (
    <div style={{ maxWidth: 800, margin: '0 auto', padding: 40, color: 'var(--text-primary)' }}>
      <h1 style={{ color: 'var(--text-primary)' }}>React 18 + TS + Tusd 断点续传示例</h1>
      <div
        style={{
          maxWidth: 600,
          margin: '20px auto',
          padding: 20,
          border: '1px solid var(--border-color-light)',
          borderRadius: 8,
          backgroundColor: 'var(--bg-secondary)',
        }}
      >
        <h3 style={{ color: 'var(--text-primary)', marginTop: 0 }}>
          Tusd 断点续传上传(React 18 + TS)
        </h3>

        <div style={{ marginBottom: 20 }}>
          <input
            type="file"
            onChange={handleFileChange}
            disabled={uploadState === 'uploading'}
            style={{
              color: 'var(--text-primary)',
              backgroundColor: 'var(--bg-tertiary)',
              border: '1px solid var(--border-color)',
              borderRadius: 4,
              padding: '8px',
              cursor: uploadState === 'uploading' ? 'not-allowed' : 'pointer',
            }}
          />
          {selectedFile && (
            <p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
              已选择:{selectedFile.name}{Math.round(selectedFile.size / 1024)} KB)
            </p>
          )}
        </div>

        <div style={{ marginBottom: 20, gap: 10, display: 'flex' }}>
          <button
            onClick={startUpload}
            disabled={!selectedFile || uploadState === 'uploading' || uploadState === 'completed'}
            style={{
              padding: '8px 16px',
              cursor:
                !selectedFile || uploadState === 'uploading' || uploadState === 'completed'
                  ? 'not-allowed'
                  : 'pointer',
              backgroundColor: 'var(--button-bg)',
              color: 'var(--text-primary)',
              border: '1px solid var(--border-color)',
              borderRadius: 4,
              opacity:
                !selectedFile || uploadState === 'uploading' || uploadState === 'completed'
                  ? 0.5
                  : 1,
            }}
          >
            {uploadState === 'paused' ? '恢复上传' : '开始上传'}
          </button>
          <button
            onClick={pauseUpload}
            disabled={uploadState !== 'uploading'}
            style={{
              padding: '8px 16px',
              cursor: uploadState !== 'uploading' ? 'not-allowed' : 'pointer',
              backgroundColor: 'var(--button-bg-secondary)',
              color: 'var(--text-primary)',
              border: '1px solid var(--border-color)',
              borderRadius: 4,
              opacity: uploadState !== 'uploading' ? 0.5 : 1,
            }}
          >
            暂停上传
          </button>
          <button
            onClick={cancelUpload}
            disabled={uploadState === 'idle' && !selectedFile}
            style={{
              padding: '8px 16px',
              cursor: uploadState === 'idle' && !selectedFile ? 'not-allowed' : 'pointer',
              backgroundColor: 'var(--button-bg-danger)',
              color: 'var(--text-primary)',
              border: '1px solid var(--border-color)',
              borderRadius: 4,
              opacity: uploadState === 'idle' && !selectedFile ? 0.5 : 1,
            }}
          >
            取消上传
          </button>
        </div>

        {uploadState !== 'idle' && (
          <div style={{ marginBottom: 20 }}>
            <div
              style={{
                height: 8,
                width: '100%',
                backgroundColor: 'var(--progress-bg)',
                borderRadius: 4,
              }}
            >
              <div
                style={{
                  height: '100%',
                  width: `${uploadProgress}%`,
                  backgroundColor:
                    uploadState === 'error' ? 'var(--error-color)' : 'var(--progress-fill)',
                  borderRadius: 4,
                  transition: 'width 0.3s ease',
                }}
              />
            </div>
            <div
              style={{
                marginTop: 8,
                display: 'flex',
                justifyContent: 'space-between',
                alignItems: 'center',
                flexWrap: 'wrap',
                gap: 8,
              }}
            >
              <div style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
                <span style={{ fontWeight: 500 }}>进度:{uploadProgress}%</span>
                <span style={{ marginLeft: 12, color: 'var(--text-secondary)' }}>
                  {formatBytes(bytesUploaded)} / {formatBytes(bytesTotal)}
                </span>
              </div>
              {uploadState === 'uploading' && uploadSpeed > 0 && (
                <div style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
                  <span style={{ marginRight: 12 }}>速度:{formatBytes(uploadSpeed)}/s</span>
                  {timeRemaining > 0 && <span>剩余:{formatTime(timeRemaining)}</span>}
                </div>
              )}
            </div>
          </div>
        )}

        {errorMessage && (
          <p style={{ color: 'var(--error-color)', marginTop: 10 }}>{errorMessage}</p>
        )}
        {uploadState === 'completed' && (
          <div style={{ marginTop: 10 }}>
            <p style={{ color: 'var(--success-color)', marginBottom: 8 }}>上传完成!</p>
            {fileUrl && (
              <div
                style={{
                  marginTop: 10,
                  padding: 12,
                  backgroundColor: 'var(--bg-tertiary)',
                  borderRadius: 4,
                  border: '1px solid var(--border-color)',
                }}
              >
                <p style={{ color: 'var(--text-primary)', margin: '0 0 8px 0', fontWeight: 500 }}>
                  文件链接:
                </p>
                <a
                  href={fileUrl}
                  target="_blank"
                  rel="noopener noreferrer"
                  style={{
                    color: 'var(--progress-fill)',
                    textDecoration: 'none',
                    wordBreak: 'break-all',
                    display: 'inline-block',
                    maxWidth: '100%',
                  }}
                  onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
                  onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
                >
                  {fileUrl}
                </a>
                <button
                  onClick={() => {
                    navigator.clipboard.writeText(fileUrl)
                  }}
                  style={{
                    marginLeft: 8,
                    padding: '4px 8px',
                    backgroundColor: 'var(--button-bg)',
                    color: 'var(--text-primary)',
                    border: '1px solid var(--border-color)',
                    borderRadius: 4,
                    cursor: 'pointer',
                    fontSize: 12,
                  }}
                >
                  复制
                </button>
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  )
}

export default App